iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 19
0
Mobile Development

從0開始,全方面自動化測試Android App系列 第 19

[Day 19] Android Espresso 測試客制化UI元件

  • 分享至 

  • xImage
  •  

在設計Mobile UI的時候免不了有一些需要客制化的共用元件,可能是你自己寫的或是引用3rd party library(如果你的Application只有用到Android原生元件那就可以跳過這一節),而這些UI元件我們如果想用Espresso去做測試的時候常常會遇到難題,例如元件找不到或動作無法執行,這是因為View Hierachy或是預設的ViewAssertion與ViewAction只能應付Android原生元件的操作。如果遇到這種狀況也有方法來解決。

  • UI元件動作無法執行的時候繼承ViewAction
  • 無法判斷UI元件條件的候候實作Matcher

舉例有一個CustomWidget的類別繼承LinearLayout來實作。包含一個TextView,一個EditText以及一個Save Button,要做的事就是把EditText的input透過Save Button的ClickListener放到TextView(placeholder)中去顯示。

https://ithelp.ithome.com.tw/upload/images/20191004/20120975J9hCFC5ZgY.png

CustomWidget程式碼

@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
class CustomWidget @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : LinearLayout(context, attrs) {

    init {
        //inflate layout
        LayoutInflater.from(context).inflate(R.layout.custom_widget, this, true)
        save.setOnClickListener {
            textView.text = editText.text
        }
    }
}

CustomWidget layout.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/wrapper"
    android:orientation="horizontal" android:layout_width="wrap_content"
    android:layout_height="wrap_content">

    <TextView
        android:id="@+id/textView"
        android:layout_width="100dp"
        android:layout_height="wrap_content" />

    <EditText
        android:id="@+id/editText"
        android:layout_width="100dp"
        android:layout_height="match_parent"
        android:gravity="center"
        android:text="item"
        android:textSize="24dp" />

    <Button
        android:id="@+id/save"
        android:text="Save"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>
    
</LinearLayout>

在MainActivity內插入CustomWidget

 <com.daniel.demotest.CustomWidget
        android:id="@+id/customWidget"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        />

我們來測試CustomWidget的功能,因為我們是放在MainActivity裡,所以一樣launch MainActivity來做測試。
我們要測試CustomWidget的Save功能是否正常,所以我們要在EditText裡輸入一個字串"testString",然後按Save觸發onClickListener後應該要在TextView裡看到"testString"這個字串。

class ExampleInstrumentedTest {

    @get:Rule
    val activityTestRule = ActivityTestRule(MainActivity::class.java)

    @Test
    fun testWidget() {
        val text = "testString"
        onView(withId(R.id.editText)).perform(replaceText(text))
        onView(withId(R.id.save)).perform(click())
        onView(withId(R.id.textView)).check(matches(withText(text)))
    }
}

看似很直覺的測試結果程式一執行就出錯了,我們來看看怎麼一回事。

照理說我們有CustomWidget的layout檔也知道裡面元件的id應該用onView(withId("R.id.save")).perform(click())就應該要按到CustomWidget裡的按鈕了,但是確會出錯。

Caused by: java.lang.RuntimeException: Action will not be performed because the target view does not match one or more of the following constraints:
at least 90 percent of the view's area is displayed to the user.

不管你怎麼把save button改變UI,Espresso就是會跟你說上述錯誤,所以這時就只好也用客制化的ViewAction直接操作CustomWidget內部元件如下。我們建立一個新的Class CustomViewAction繼承ViewAction後,有下列三個functions需要override

  • getDescription
    • 在執行時會print出這段description
  • getConstraints
    • 針對Parent View設定被filter的元件,isDisplayed()就是看到的都拿來操控
  • perform
    • 這裡會傳入view的instance,也就是CustomWidget本身,因此我們可以用view.findViewById的方式來獲取所需元件。uiController則可以對目前畫面下達指令如loopMainThreadForAtLeast(milsecs)。而我們這裡用手動方式把editText跟save button找出來並操作它們。只要傳入string就會自動執行editText的文字設定及save button的çlick event。
class CustomViewAction(private val text: String) : ViewAction {
        override fun getDescription(): String {
            return "CustomViewAction applied $text"
        }

        override fun getConstraints(): Matcher<View> {
            return isDisplayed()
        }

        override fun perform(uiController: UiController?, view: View?) {
            val editText = view!!.findViewById<EditText>(R.id.editText)
            val save = view.findViewById<Button>(R.id.save)
            editText.text.clear()
            editText.text.append(text)
            save.callOnClick()
        }
    }

下一步要測試CustomWidget的TextView是否真的有收到字串,我們就用onView(withId(R.id.textView)).check(matches(withText(text))),結執行這行也出錯了,

androidx.test.espresso.AmbiguousViewMatcherException: 'with id: com.daniel.demotest:id/textView' matches multiple views in the hierarchy.

因為MainActivity裡我們己經有一個元件叫textView了,Espresso不知道要抓哪一個textView來判斷,這時候有一個解法就是你把其中一個id叫textView的改掉,但這樣做不好,難不成為了測試我們每個View的ID都要取不重複的名稱。因此我們可以用實作BoundedMatcher介面的方法來做判斷,這裡我們建立一個customTextViewMatcher來實作BoundedMatcher,這裡必須override兩個functions

  • describeTo
    • 用來從Description物件print log
  • matchesSafely
    • view參數會把呼叫的parent回傳,這裡就是CustomWidget,我們一樣用findViewById的方法把textView找出來並做字串的判斷。
    private fun customTextViewMatcher(text: String) : BoundedMatcher<View, CustomWidget>  {
        Checks.checkNotNull(text)
        return object : BoundedMatcher<View, CustomWidget>(CustomWidget::class.java) {
            override fun describeTo(description: Description?) {
            }

            override fun matchesSafely(view: CustomWidget?): Boolean {
                val textView = view?.findViewById<TextView>(R.id.textView)
                return text == textView?.text.toString()
            }
        }
    }

最後我們在testWidget()改成先對CustomWidget perform我們客制化的CustomViewAction後再用客制化的customTextViewMatcher去判斷就完成了對客制化元件CustomWidget測試

class ExampleInstrumentedTest {

    @get:Rule
    val activityTestRule = ActivityTestRule(MainActivity::class.java)

    @Test
    fun testWidget() {
        val text = "testString"
        onView(withId(R.id.customWidget))
            .perform(CustomViewAction(text))
        onView(withId(R.id.customWidget))
            .check(matches(customTextViewMatcher(text)))
    }
}

在這一章節我們用很基本的範例來示範Custom Widget的測試方式,大致上就是用parent的layouot去抓取child view的細節來進一步的操作其實很簡單,下一節我們來講講如果Application有很多的Server連線要怎麼在Integration test做處理。


上一篇
[Day 18] Android Espresso 介紹
下一篇
[Day 20] Mock Server's Response
系列文
從0開始,全方面自動化測試Android App30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言